<script setup>
import { ref, watch, nextTick } from 'vue';

const props = defineProps({
  data: {
    type: Array,
    default: () => [],
  },
  height: {
    type: Number,
    default: undefined,
  },
  maxHeight: {
    type: Number,
    default: undefined,
  },
  // 最小行高
  minItemHeight: {
    type: Number,
    default: 20,
  },
  // 预先多加载的列表数量（值越大，数据更新的频率越低，每次更新的数据则越多）
  cache: {
    type: Number,
    default: 2,
  },
  // 滚动时需要的缓冲量（值越大滚动时视口上下留白的几率越小）
  buffer: {
    type: Number,
    default: 1,
  },
  // 列表中每一项的高度是否相同
  fixed: {
    type: Boolean,
    default: true,
  },
  // 数据变化时自动重置滚动条
  autoResetScrollBar: {
    type: Boolean,
    default: false,
  },
});

let list = [];
let total = 0;

const viewportHeight = ref(null);
let itemHeight = 0;
let viewportCount = 0; // 视口区域中加载的列表数量
let viewporOutsideCount = 0; // 视口区域之外需要额外加载的列表数量
const loadList = ref([]); // 每次实际加载的列表
let loadListTotal = 0; // 每次实际加载的列表总数
let startIndex = 0; // 每次实际加载的列表中第一个元素的索引
let endIndex = 0; // 每次实际加载的列表中最后一个元素的索引

const paddingTop = ref(0); // 上内边距
const paddingBottom = ref(0); // 下内边距

const listWrapRef = ref(null);
const listRef = ref(null);

let listHeights = []; // 记录列表中每个元素的高度
let ticking = false;

// 视口区域之外需要额外加载的列表数量
const getViewportOutsideCount = () => {
  let count = props.cache + props.buffer * 2;
  // 不能小于2
  if (count < 2) {
    count = 2;
  }
  return count;
};

// 获取列表中每一项的高度
function getItemHeight(el) {
  const height = el.getBoundingClientRect().height;
  return Math.round(height * 100) / 100;
}

// 记录非定高的列表中每个元素的高度
const getListHeight = () => {
  // 记录已加载的列表中每个元素的高度
  const listChilds = listRef.value.children;
  const heights = [];
  for (let i = 0; i < listChilds.length; i++) {
    heights.push(getItemHeight(listChilds[i]));
  }

  // 未加载的列表按照最小行高来计算
  for (let i = heights.length; i < list.length; i++) {
    heights.push(props.minItemHeight);
  }

  return heights;
};

const getStartIndex = () => {
  if (!listWrapRef.value) return 0;

  const scrollTop = listWrapRef.value.scrollTop;
  if (scrollTop === 0) return 0;

  let startIndex = 0;
  if (props.fixed) {
    startIndex = Math.floor(scrollTop / itemHeight);
  } else {
    let height = 0;
    for (let i = 0; i < listHeights.length; i++) {
      height += listHeights[i];

      if (scrollTop === height) {
        startIndex = i + 1;
        break;
      }

      if (scrollTop < height) {
        startIndex = i;
        break;
      }
    }
  }

  startIndex -= Math.floor(viewporOutsideCount / 2);

  const maxStartIndex = total - loadListTotal;
  if (startIndex < 0) {
    startIndex = 0;
  } else if (startIndex > maxStartIndex) {
    startIndex = maxStartIndex;
  }
  return startIndex;
};

const getEndIndex = (startIndex) => {
  let endIndex = startIndex + loadListTotal - 1;
  const maxEndIndex = total - 1;
  if (endIndex > maxEndIndex) {
    endIndex = maxEndIndex;
  }
  return endIndex;
};
// 计算实际加载列表的顶部到视口顶部的距离
const getTopDistance = () => {
  const scrollTop = listWrapRef.value.scrollTop;
  if (startIndex === 0) return scrollTop;

  let distance = 0;
  if (props.fixed) {
    distance = scrollTop - startIndex * itemHeight;
  } else {
    let height = 0;
    for (let i = 0; i < startIndex; i++) {
      height += listHeights[i];
    }
    distance = scrollTop - height;
  }
  return distance;
};

const getBottomDistance = () => {
  const scrollTop = listWrapRef.value.scrollTop;
  let distance = 0;
  if (props.fixed) {
    distance = (endIndex + 1) * itemHeight - scrollTop - viewportHeight.value;
  } else {
    let height = 0;
    for (let i = 0; i <= endIndex; i++) {
      height += listHeights[i];
    }
    distance = height - scrollTop - viewportHeight.value;
  }
  return distance;
};

const isUpdateLoadList = () => {
  // 实际加载列表的顶部到视口顶部的距离
  const topDistance = getTopDistance();
  // 实际加载列表的底部到视口底部的距离
  const bottomDistance = getBottomDistance();

  if (props.fixed) {
    // 缓冲距离
    const bufferDistance = props.buffer * itemHeight;

    // 视口超出列表上下的缓冲距离，需要更新列表
    if (topDistance < bufferDistance || bottomDistance < bufferDistance) {
      return true;
    }
    return false;
  } else {
    let topBufferDistance = 0;
    let bottomBufferDistance = 0;
    for (let i = startIndex; i < startIndex + props.buffer; i++) {
      topBufferDistance += listHeights[i];
    }
    for (let i = endIndex + 1 - props.buffer; i <= endIndex; i++) {
      bottomBufferDistance += listHeights[i];
    }

    // 视口超出列表上下的缓冲距离，需要更新列表
    if (
      topDistance < topBufferDistance ||
      bottomDistance < bottomBufferDistance
    ) {
      return true;
    }
    return false;
  }
};

const loadAll = () => {
  loadList.value = list;
  paddingTop.value = 0;
  paddingBottom.value = 0;
};

const initLoadList = () => {
  // 计算视口区域加载的列表数量
  viewportCount = Math.ceil(viewportHeight.value / itemHeight);
  // 计算视口区域之外需要额外加载的列表数量
  viewporOutsideCount = getViewportOutsideCount();

  loadListTotal = viewportCount + viewporOutsideCount;

  if (loadListTotal >= total) {
    loadAll();
    return;
  }

  startIndex = getStartIndex();
  endIndex = getEndIndex(startIndex);

  paddingTop.value = startIndex * itemHeight;
  paddingBottom.value = (total - endIndex - 1) * itemHeight;

  loadList.value = list.slice(startIndex, endIndex + 1);

  if (!props.fixed) {
    nextTick(() => {
      listHeights = getListHeight();
    });
  }
};

const init = () => {
  list = props.data.map((item, index) => {
    return {
      id: `${Date.now()}-${index}`,
      data: item,
    };
  });
  total = list.length;

  viewportHeight.value = props.height || props.maxHeight;

  if (total === 0 || !viewportHeight.value) {
    loadAll();
    return;
  }

  if (props.fixed) {
    // 先加载一个或多个元素
    loadList.value = list.slice(0, loadListTotal || 1);

    nextTick(() => {
      // 取列表中第一个元素的高度作为每一项的高度
      const listChilds = listRef.value.children;
      itemHeight = listChilds[0].clientHeight;

      initLoadList();
    });
  } else {
    // 非定高列表的每一项默认按最小行高来计算
    itemHeight = props.minItemHeight;
    initLoadList();
  }
};

const updateLoadList = () => {
  // 是否更新加载的列表
  if (!isUpdateLoadList()) return;

  const newStartIndex = getStartIndex();

  if (newStartIndex === startIndex) return;

  startIndex = newStartIndex;
  endIndex = getEndIndex(startIndex);

  loadList.value = list.slice(startIndex, endIndex + 1);

  if (props.fixed) {
    paddingTop.value = startIndex * itemHeight;
    paddingBottom.value = (total - endIndex - 1) * itemHeight;
  } else {
    let topHeigth = 0;
    for (let i = 0; i < startIndex; i++) {
      topHeigth += listHeights[i];
    }
    paddingTop.value = topHeigth;

    let bottomHeight = 0;
    for (let i = endIndex + 1; i < total; i++) {
      bottomHeight += listHeights[i];
    }
    paddingBottom.value = bottomHeight;

    nextTick(() => {
      const listChilds = listRef.value.children;
      const heights = [];
      for (let i = 0; i < listChilds.length; i++) {
        heights.push(getItemHeight(listChilds[i]));
      }
      listHeights.splice(startIndex, heights.length, ...heights);
    });
  }
};

const scrollTo = (scrollTop) => {
  if (listWrapRef.value) {
    listWrapRef.value.scrollTop = scrollTop;
  }
};

watch(
  () => props.data,
  () => {
    if (props.autoResetScrollBar) {
      // 重置滚动条
      scrollTo(0);
    }

    init();
  },
  {
    immediate: true,
    deep: true,
  },
);

watch([() => props.height, () => props.maxHeight], () => {
  viewportHeight.value = props.height || props.maxHeight;
  if (!viewportHeight.value) {
    loadAll();
    return;
  }
  initLoadList();
});

const scrollTop = ref(0);

const scrollHandler = (event) => {
  scrollTop.value = event.target.scrollTop;

  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    ticking = false;
  });

  if (loadListTotal >= total) return;

  updateLoadList();
};

defineExpose({
  scrollTop,
  scrollTo,
});
</script>

<template>
  <div class="virtual-list">
    <div
      ref="listWrapRef"
      class="list-wrap"
      :style="{
        height: height ? height + 'px' : '',
        'max-height': viewportHeight ? viewportHeight + 'px' : '',
      }"
      @scroll="scrollHandler"
    >
      <ul
        ref="listRef"
        :style="{
          'padding-top': paddingTop + 'px',
          'padding-bottom': paddingBottom + 'px',
        }"
      >
        <li
          v-for="item in loadList"
          :key="item.id"
          class="list-item"
          :style="{
            'white-space': fixed ? 'nowrap' : 'normal',
            'word-break': fixed ? 'normal' : 'break-all',
            overflow: fixed ? 'hidden' : 'visible',
          }"
        >
          <slot :item="item.data">{{ item }}</slot>
        </li>
      </ul>
    </div>
  </div>
</template>

<style scoped>
.virtual-list {
  position: relative;

  .list-wrap {
    overflow-y: auto;

    .list-item.tooltip {
      text-overflow: ellipsis;
    }
  }
}
</style>
